核心思想:用哈希函数把相似向量映射到同一个桶
哈希函数
Hash的本质是将任意长度的输入转化为固定长度的编码。相同的输入一定产生相同的编码,不同的输入则产生不同的编码。
举个具体的例子
# 传统哈希(如MD5、SHA)的特点
import hashlib
text1 = "hello"
text2 = "hello" # 完全相同
text3 = "hallo" # 只有一个字母不同
hash1 = hashlib.md5(text1.encode()).hexdigest()
hash2 = hashlib.md5(text2.encode()).hexdigest()
hash3 = hashlib.md5(text3.encode()).hexdigest()
print(f"text1哈希: {hash1[:16]}...") # 5d41402abc4b2a76...
print(f"text2哈希: {hash2[:16]}...") # 5d41402abc4b2a76... (与text1相同)
print(f"text3哈希: {hash3[:16]}...") # 437b930db84b8079... (完全不同)
传统哈希的三大特点
正因为这些特点,传统哈希常用在:密码存储(确定性)、文件校验(雪崩效应检测篡改)、快速查找(平均分布的哈希表)、去重(确定性判断相同)等场景。
但这对向量检索没用,哈希的特点恰恰是向量检索最不想要的:
向量1: [0.8, 0.9, 0.1] → 哈希 → "a3f8d9e2"
向量2: [0.75, 0.85, 0.15] → 哈希 → "b7c4e1f5" # 虽然很相似,但哈希完全不同
向量3: [0.1, 0.2, 0.9] → 哈希 → "c9a2b6d3"结果:无法通过哈希值判断向量1和向量2相似
LSH的创新:反其道而行之
LSH(Locality-Sensitive Hashing)的核心思想:相似的向量应该有更高概率被映射到相同的哈希值。
这恰好与传统哈希相反:
工作原理
LSH 使用超平面(分割线)来切分空间,相似的向量因为离得近,所以大概率在同一侧。
关于超平面:
简单理解,超平面就是高维空间中的"分界线"。2D平面可以用一条直线分割,3D空间可以用是一个2D平面来分割。不需要深究数学定义,只需要知道它的作用是"把空间一分为二"就行。
尤其不要去想象高维度的向量(1535维)到底是怎么被划分的。人类是3维生物,很难想象高维度的世界,这些只存储于数学中,而非能想象出来。
具体算法示例
假设有三个向量:
步骤1:随机生成一条分割线 比如:x + y = 1
y轴
↑
1| \ ●C
| \
| \ ●A
| \
| \
| ●B ●D \
0|_________\____→ x轴
0 1
分割线: x + y = 1 (从(0,1)到(1,0)的斜线)
步骤2:判断每个向量在线的哪一侧
用分割线 x + y = 1 来判断。
判断规则:
x + y > 1,向量在分割线右侧x + y < 1,向量在分割线左侧可以看到:
线左侧(x+y<1): B、D;
线右侧(x+y>1): A、C;
步骤3:相同哈希码的分到同一桶
查询时
查询向量: [0.78, 0.88]
↓ 计算:0.78 + 0.88 = 1.66 > 1
↓ 落入 bucket_1
↓ 只在 bucket_1 中搜索
找到向量1、向量2(跳过向量3)
选择建议
聊完了向量数据库的几种搜索模式,看看市面上常见的向量数据库。
特点
索引算法
特点
索引算法
下面用Chroma来实现一个完整的向量检索流程。
环境准备
在开始之前,需要安装必要的Python库:
# 安装Chroma向量数据库
pip install chromadb
# 安装通义千问SDK(用于生成向量)
pip install dashscope
依赖说明
chromadb:轻量级向量数据库,支持本地存储和查询dashscope:阿里云通义千问SDK,提供Embedding API核心代码示例
Tip
完整源码参考:samples/chapter5/chroma_faq_search.py
下面展示关键步骤的核心伪代码:
# 步骤1:创建客户端和集合
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.create_collection(
name="company_faq",
metadata={"hnsw:space": "cosine"} # 文本检索必须指定!
)
# 步骤2:生成向量并存储
documents = ["如何申请年假? 员工入职满一年后...", ...] # FAQ文本
embeddings = embedding_api.call(documents) # 调用API生成向量
collection.add(ids=ids, embeddings=embeddings, metadatas=metadatas)
# 步骤3:查询检索
query_embedding = embedding_api.call("年假怎么申请?") # 查询向量化
results = collection.query(query_embeddings=[query_embedding], n_results=3)
# 步骤4:处理结果
for metadata, distance in zip(results['metadatas'][0], results['distances'][0]):
similarity = 1 - distance # 转为相似度
print(f"相似度: {similarity:.4f}")
print(f"答案: {metadata['answer']}")
运行输出示例
正在生成向量并存储...
成功存储 5 条FAQ
查询: 年假怎么申请?
找到 3 条相关FAQ:
#1 [相似度: 0.7640] [人事制度]
问题: 如何申请年假?
答案: 员工入职满一年后,可在OA系统提交年假申请,需提前3天申请,由直属领导审批。
#2 [相似度: 0.5762] [人事制度] ...
#3 [相似度: 0.5065] [人事制度] ...
查询: 请假需要准备什么材料?
找到 3 条相关FAQ:
#1 [相似度: 0.5762] [人事制度]
问题: 病假需要什么证明?
答案: 病假需提供医院诊断证明,3天以上需提交病假条,由HR审批。
#2 [相似度: 0.4790] [人事制度] ...
#3 [相似度: 0.4056] [人事制度] ...
在深入代码细节之前,先了解 Chroma 的核心结构。
Chroma 采用简洁的两层设计:
客户端 (Client)
|
+- 集合1(Collection): company_faq
+- 集合2(Collection): product_docs
+- 集合3(Collection): ...
PersistentClient(path="./chroma_db") — 数据保存到磁盘Client() — 数据只存在内存,重启后消失company_faq)就像在 MySQL 中先连接数据库,然后操作具体的表一样:Chroma 先创建客户端,再在客户端下创建多个集合。
# 创建集合时指定使用余弦相似度
collection = client.create_collection(
name="company_faq",
metadata={
"description": "公司FAQ知识库",
"hnsw:space": "cosine" # 使用余弦相似度
}
)
为什么创建集合时要指定相似度算法?
向量数据库不只是存储向量,更重要的是要考虑后续的查询。而查询非常依赖索引,不同的相似度算法会导致完全不同的索引组织方式:
这就像建图书馆:
如果不设置会怎样
一旦建好索引,就无法更改算法了。所以文本检索必须在创建集合时显式指定"hnsw:space": "cosine"
Chroma相似度计算方式对比
| 计算方式 | 参数值 | 适用场景 | 相似度范围 | 说明 |
|---|---|---|---|---|
| 余弦相似度(推荐) | "cosine" |
文本、语义 | [0, 1] | 1表示完全相同,0表示无关 |
| 欧氏距离 | "l2" |
图像、坐标 | 数值复杂 | 不推荐用于文本 |
| 内积 | "ip" |
归一化向量 | 数值复杂 | 特殊场景使用 |
# 持久化:数据保存到磁盘,重启不丢失
# 代码执行后会在当前Python文件的同级创建一个名为chroma_db的目录,并存储数据库文件
client = chromadb.PersistentClient(path="./chroma_db")
# 内存:数据只在内存,重启后丢失(适合测试)
client = chromadb.Client()
以下4个参数是Chroma定义的标准参数,不是随意定义的。
collection.add(
ids=ids, # 唯一标识符
embeddings=embeddings, # 向量(用于检索)
documents=documents, # 原始文本(用于调试)
metadatas=metadatas # 业务数据(用于展示)
)
各个参数的作用
| 参数 | 必须 | 作用 | 举例 |
|---|---|---|---|
ids |
是 | 唯一标识,用于更新/删除 | "faq_001" |
embeddings |
是 | 向量数据,用于相似度检索 | [0.123, -0.456, ...] |
documents |
可选 | 原始文本,即向量数据对应的文本 | "如何申请年假?..." |
metadatas |
可选 | 业务数据,返回给用户展示 | {"question": "...", "answer": "..."} |
为什么需要 documents
虽然检索只用 embeddings,但存储 documents 有两个好处:
为什么需要 metadatas
metadatas 是返回给用户的关键信息:
# 检索后返回的是 metadata,不是embedding
for metadata in results['metadatas'][0]:
print(f"问题: {metadata['question']}")
print(f"答案: {metadata['answer']}")
print(f"类别: {metadata['category']}")
如果不存 metadatas,检索结果只有 ID,还需要去其他数据库查询详情,非常麻烦。
最佳实践
ids: 必须唯一,建议用有意义的编号(如 faq_001)embeddings: 必须与API返回的向量一致documents: 建议存储,方便调试metadatas: 存储所有需要展示的字段(问题、答案、类别等)其实代码:
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k
)
已经查询出了最相似的k条结果,results就是这个结果。
后续的代码在做什么? Chroma确实查询出了结果,但它的结果指标并不是相似度,而是distance(距离)。
# Chroma返回的是distance(余弦距离)
results = collection.query(
query_embeddings=[query_embedding],
n_results=3
)
# results['distances'][0] = [0.24, 0.42, 0.49] # 这是距离值,不是相似度!
# 需要转换成相似度才好理解
for distance in results['distances'][0]:
similarity = 1 - distance # 转换公式
print(f"相似度: {similarity:.4f}") # 0.76, 0.58, 0.51
这是余弦距离的数学定义:余弦距离 = 1 - 余弦相似度。
余弦相似度 = cosθ(θ 是向量夹角),范围 [-1, 1]。为了获得一个"距离"指标,数学上直接用 1 - cosθ 来定义余弦距离。三个边界值对比如下:
| 场景 | cosθ | 余弦相似度 | 余弦距离 |
|---|---|---|---|
| 完全相同 | 1 | 1 | 0 |
| 完全相反 | -1 | -1 | 2 |
| 正交(无关) | 0 | 0 | 1 |
可以看到:余弦相似度越高(接近 1),余弦距离越小(接近 0)。数值一一对应,用 相似度 = 1 - 距离 就能互相转换。
Chroma 配置了 "hnsw:space": "cosine" 后,内部计算的就是余弦距离。所以从 results 拿到 distance 后,用 1 - distance 就能还原回余弦相似度。
下一节预告
现在已经掌握了向量生成、向量检索的完整流程。但在实际项目中,还有关键问题:如何把文档切分成合适的片段?
一份完整的技术手册可能有几十页,是整个文档生成一个向量?还是按章节切分?按段落切分?切得太大会导致检索不精准,切得太小又会丢失上下文。
下一节《第一个RAG应用:游戏知识问答》,实战一个完整的RAG应用,从文档处理、向量化、检索到生成,把前面学到的所有知识串起来。
| 中文 | English | 音标 | 说明 |
|---|---|---|---|
| 局部敏感哈希 | Locality-Sensitive Hashing (LSH) | /loʊˈkæləti ˈsensətɪv ˈhæʃɪŋ/ | 将相似向量映射到同一哈希桶的近似搜索算法 |
| HNSW 图 | HNSW Graph | /eɪtʃ en es ˈdʌbəl juː ɡræf/ | 分层小世界图,通过多层跳表结构实现对数级搜索复杂度 |
| Chroma | Chroma | /ˈkroʊmə/ | 轻量级开源向量数据库,适合学习和原型开发 |
| 余弦距离 | Cosine Distance | /ˈkoʊsaɪn ˈdɪstəns/ | 1 - 余弦相似度,Chroma中配置cosine后返回的距离值 |
| 向量集合 | Collection | /kəˈlekʃn/ | Chroma中组织和存储向量的基本单位 |